创建 SSH 客户端 SshService 服务与测试
SshService 设计
SshService 是 SSH 模块的核心服务,负责管理 SSH 连接和执行远程命令。它通过依赖注入获取连接配置,封装了连接管理和命令执行的完整流程。
import { Injectable, Inject } from '@nestjs/common'
import { Client } from 'ssh2'
import { readFileSync } from 'fs'
import { SSH_OPTIONS, SshModuleOptions } from './ssh.interfaces'
@Injectable()
export class SshService {
private client: Client = new Client()
private isConnected: boolean = false
constructor(
@Inject(SSH_OPTIONS)
private options: SshModuleOptions
) {}
}
typescript
连接管理
SSH 连接的建立是异步过程,我们通过 Promise 封装 ssh2 的事件驱动 API:
private connect(): Promise<boolean> {
return new Promise((resolve, reject) => {
if (this.isConnected) {
resolve(true)
return
}
// 处理 privateKey 文件路径
const newOptions = { ...this.options }
if (this.options.privateKey && typeof this.options.privateKey === 'string') {
try {
newOptions.privateKey = readFileSync(this.options.privateKey as string)
} catch {
// 如果不是文件路径,保留原始值
}
}
this.client
.on('ready', () => {
this.isConnected = true
resolve(true)
})
.on('error', (err) => {
reject(err)
})
.connect(newOptions)
})
}
typescript
连接管理的设计要点:
- 单例连接:使用
isConnected标志避免重复连接 - privateKey 支持:自动检测
privateKey是否为文件路径,如果是则读取文件内容 - 错误处理:通过
error事件捕获连接失败的情况
远程命令执行
async exec(command: string): Promise<{
stdout: string
code: number
signal: string
}> {
await this.connect()
return new Promise((resolve, reject) => {
this.client.exec(command, (err, stream) => {
if (err) {
reject(err)
return
}
let stdout = ''
let stderr = ''
stream
.on('data', (data: Buffer) => {
stdout += data.toString()
})
.stderr.on('data', (data: Buffer) => {
stderr += data.toString()
})
stream.on('close', (code: number, signal: string) => {
resolve({ stdout, code, signal })
})
})
})
}
typescript
执行流程:
exec(command)
└── connect() 确保连接已建立
└── client.exec(command) 发送命令到远程服务器
└── stream.data 收集标准输出
└── stream.stderr.data 收集错误输出
└── stream.close 命令执行完成,返回结果
text
关于 Stream 数据处理的注意事项:
stream是一个 Channel 对象,数据可能分多次到达(不是一次性返回全部数据)- 必须在
close事件中 resolve Promise,确保收集到所有数据 stderr数据需要单独监听,它不在主data事件中
Provider 注册
SshService 必须在核心模块的 providers 数组中注册,并在 exports 中导出:
@Global()
@Module({})
export class SshCoreModule {
static forRoot(options: SshModuleOptions): DynamicModule {
return {
module: SshCoreModule,
providers: [
{ provide: SSH_OPTIONS, useValue: options },
SshService, // 必须注册为 Provider
],
exports: [SshService], // 必须导出
}
}
}
typescript
如果忘记注册 SshService,NestJS 启动时会抛出依赖注入错误。
功能测试
1. 在 Controller 中注入 SshService
import { Controller, Post, Body } from '@nestjs/common'
import { SshService } from '../utils/ssh/ssh.service'
@Controller('auth')
export class AuthController {
constructor(private sshService: SshService) {}
@Post('test')
async test(@Body('cmd') cmd: string) {
return this.sshService.exec(cmd)
}
}
typescript
2. 在 AppModule 中注册 SSH 模块
import { SshModule } from './utils/ssh/ssh.module'
@Module({
imports: [
SshModule.forRoot({
host: '192.168.31.77',
port: 22,
username: 'root',
password: '123456',
}),
],
})
export class AppModule {}
typescript
3. 发起测试请求
# 测试 ls 命令
curl -X POST http://localhost:3000/api/v1/auth/test \
-H "Content-Type: application/json" \
-d '{"cmd": "ls -la /tmp"}'
# 测试 docker 命令
curl -X POST http://localhost:3000/api/v1/auth/test \
-H "Content-Type: application/json" \
-d '{"cmd": "docker ps"}'
# 测试 docker 版本
curl -X POST http://localhost:3000/api/v1/auth/test \
-H "Content-Type: application/json" \
-d '{"cmd": "docker version"}'
bash
成功响应示例:
{
"stdout": "total 24\ndrwxr-xr-x 6 root root 4096 Jan 1 12:00 .\n...",
"code": 0,
"signal": ""
}
json
安全注意事项
通过 HTTP 接口直接传递并执行 SSH 命令存在严重的安全风险:
- 命令注入:恶意用户可能执行系统破坏性命令(如
rm -rf /、修改密码等) - 权限泄露:以 root 身份执行命令等同于拥有服务器的完全控制权
生产环境中应采取以下措施:
- 白名单机制:只允许执行预定义的安全命令
- 权限最小化:使用低权限用户连接 SSH
- 接口鉴权:限制只有管理员可以访问 SSH 相关接口
- 输入校验:对传入的命令进行严格过滤
// 安全实现示例:白名单命令
const ALLOWED_COMMANDS = [
'mongodump',
'mongorestore',
'ls',
'docker ps',
]
@Post('exec')
async exec(@Body('cmd') cmd: string) {
const isAllowed = ALLOWED_COMMANDS.some(allowed => cmd.startsWith(allowed))
if (!isAllowed) {
throw new ForbiddenException('Command not allowed')
}
return this.sshService.exec(cmd)
}
typescript
小结
- SshService 封装了 SSH 连接管理和远程命令执行的完整流程
- 连接使用单例模式,避免重复建立连接
- 支持
privateKey文件路径自动读取 - 命令执行结果包含
stdout、code、signal三个字段 - 通过
SshModule.forRoot()注册后,可在任意 Controller 中注入SshService - 生产环境务必实施命令白名单、权限最小化和接口鉴权等安全措施
- 有了 SshService,后续可以通过远程执行
mongodump/mongorestore命令来实现自动化的数据库备份与恢复
↑